Decisões e por quê
Cada peça resolve uma necessidade específica do PRD. Nada inventado, nada genérico.
Node 22 LTS + TypeScript 5.6
strict:true, noImplicitAny, sem any em domínio.
NestJS 10 · modular
DI nativo, guards/interceptors, controllers REST + microservices Redis pra workers.
PostgreSQL 16 + Prisma 5
Prisma Migrate pra schema; pg_partman em eventos do Pixel; pgvector pra embeddings.
Redis 7 + BullMQ
Cache de sessão, rate-limit, pub/sub realtime, e filas de jobs de IA / pixel / disparos.
Better-Auth + Magic Link
Sessões JWT + refresh em cookie HttpOnly; magic link por padrão, senha opcional.
Zod + class-validator
DTOs validados em runtime; tipos derivados de Zod compartilháveis com front.
Anthropic + Replicate
Claude pra texto, Flux/Kling pra imagem & vídeo; wrappers próprios com fallback e budget.
Stripe + Pagar.me
Stripe pra cartão/internacional, Pagar.me pra PIX BR; webhooks unificados.
Estrutura do repositório
Turborepo + pnpm workspaces. Apps independentes, packages compartilhados, deploy isolado.
expoent/ ├── apps/ │ ├── api/ # NestJS · core API REST │ ├── workers/ # BullMQ workers · IA, pixel, e-mails │ ├── web/ # Next.js · painel + landing │ └── pixel/ # script público (já existe: pixel.js) │ ├── packages/ │ ├── db/ # Prisma schema + migrations + seed │ ├── shared/ # tipos, schemas Zod, utilitários │ ├── ai/ # wrappers Anthropic/Replicate + provider │ ├── auth/ # Better-Auth config + middleware │ └── eslint-config/ # preset compartilhado │ ├── infra/ │ ├── docker-compose.yml │ ├── docker-compose.dev.yml │ ├── Dockerfile.api │ ├── Dockerfile.workers │ └── nginx.conf │ ├── .github/workflows/ │ ├── ci.yml # test + lint + typecheck │ └── deploy.yml # build + push + migrate │ ├── pnpm-workspace.yaml ├── turbo.json └── package.json
docker-compose.dev.yml
Tudo que o dev precisa em docker compose up: Postgres + Redis + Mailhog (e-mail) + MinIO (storage local).
services:
postgres:
image: postgres:16-alpine
environment:
POSTGRES_DB: expoent
POSTGRES_USER: expoent
POSTGRES_PASSWORD: dev
ports: ["5432:5432"]
volumes:
- pg_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U expoent"]
interval: 5s
redis:
image: redis:7-alpine
ports: ["6379:6379"]
command: redis-server --maxmemory 256mb --maxmemory-policy allkeys-lru
mailhog:
image: mailhog/mailhog:latest
ports: ["1025:1025", "8025:8025"] # SMTP + Web UI
minio:
image: minio/minio:latest
command: server /data --console-address ":9001"
ports: ["9000:9000", "9001:9001"]
environment:
MINIO_ROOT_USER: expoent
MINIO_ROOT_PASSWORD: dev123456
volumes:
pg_data:
$ docker compose -f infra/docker-compose.dev.yml up -d $ pnpm install $ pnpm db:migrate $ pnpm dev
Schema Prisma · núcleo
Multi-tenant via workspaceId em toda tabela de domínio. Índices compostos pra queries hot.
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
extensions = [pgvector, pg_partman, uuid_ossp]
}
// ============ TENANCY ============
model Workspace {
id String @id @default(uuid)
slug String @unique
name String
plan Plan @default(STARTER)
createdAt DateTime @default(now())
members Membership[]
brandKits BrandKit[]
contents Content[]
leads Lead[]
events PixelEvent[]
jobs AiJob[]
}
model User {
id String @id @default(uuid)
email String @unique
name String?
createdAt DateTime @default(now())
memberships Membership[]
}
model Membership {
id String @id @default(uuid)
workspaceId String
userId String
role Role @default(EDITOR)
workspace Workspace @relation(fields: [workspaceId], references: [id])
user User @relation(fields: [userId], references: [id])
@@unique([workspaceId, userId])
}
// ============ CONTEÚDO ============
model Content {
id String @id @default(uuid)
workspaceId String
brandKitId String
type ContentType
title String
status ContentStatus @default(DRAFT)
viralScore Int?
channels Channel[]
publishAt DateTime?
createdById String
createdAt DateTime @default(now())
workspace Workspace @relation(fields: [workspaceId], references: [id])
brandKit BrandKit @relation(fields: [brandKitId], references: [id])
metrics ContentMetric[]
@@index([workspaceId, status, publishAt])
}
// ============ IA & FILAS ============
model AiJob {
id String @id @default(uuid)
workspaceId String
type AiJobType // IMAGE | VIDEO | TEXT | EMBEDDING
provider AiProvider // ANTHROPIC | REPLICATE | OPENAI
model String
status JobStatus @default(QUEUED)
prompt String
input Json
output Json?
costCents Int @default(0)
durationMs Int?
errorMsg String?
createdAt DateTime @default(now())
finishedAt DateTime?
@@index([workspaceId, status, createdAt])
}
// ============ PIXEL ============
model PixelEvent {
id BigInt @id @default(autoincrement())
workspaceId String
visitorId String
sessionId String
event String
url String?
path String?
props Json?
attrSource String?
attrCampaign String?
device String?
ts DateTime @default(now())
@@index([workspaceId, ts])
@@index([workspaceId, visitorId, ts])
}
// pg_partman criará partições mensais via gatilho.
// ============ ENUMS ============
enum Plan { STARTER · PRO · AGENCY }
enum Role { OWNER · ADMIN · EDITOR · VIEWER }
enum ContentType { POST · CAROUSEL · REEL · VIDEO · EMAIL · WHATSAPP · LANDING }
enum ContentStatus { DRAFT · SCHEDULED · PUBLISHED · ARCHIVED }
enum Channel { INSTAGRAM · FACEBOOK · TIKTOK · WHATSAPP · EMAIL · BLOG · LINKEDIN · YOUTUBE }
enum AiJobType { IMAGE · VIDEO · TEXT · EMBEDDING }
enum AiProvider { ANTHROPIC · REPLICATE · OPENAI · ELEVENLABS }
enum JobStatus { QUEUED · RUNNING · DONE · FAILED · CANCELLED }
apps/api/src/main.ts
Compression, helmet, CORS configurado, Pino logger, Sentry, shutdown gracioso. Pronto pra prod.
import { NestFactory } from '@nestjs/core';
import { ValidationPipe, VersioningType } from '@nestjs/common';
import { FastifyAdapter, NestFastifyApplication } from '@nestjs/platform-fastify';
import compression from '@fastify/compress';
import helmet from '@fastify/helmet';
import * as Sentry from '@sentry/node';
import { Logger } from 'nestjs-pino';
import { AppModule } from './app.module';
import { env } from './env';
async function bootstrap() {
Sentry.init({ dsn: env.SENTRY_DSN, environment: env.NODE_ENV });
const app = await NestFactory.create<NestFastifyApplication>(
AppModule,
new FastifyAdapter({ trustProxy: true, bodyLimit: 20 * 1024 * 1024 }),
{ bufferLogs: true },
);
app.useLogger(app.get(Logger));
app.setGlobalPrefix('api');
app.enableVersioning({ type: VersioningType.URI, defaultVersion: '1' });
app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true }));
app.enableCors({
origin: env.WEB_URL.split(','),
credentials: true,
});
await app.register(compression as any);
await app.register(helmet as any, { contentSecurityPolicy: false });
app.enableShutdownHooks();
await app.listen(env.PORT, '0.0.0.0');
console.log(`✓ API up · :${env.PORT}`);
}
bootstrap();
WorkspaceGuard · isolamento por tenant
Toda rota autenticada exige X-Workspace-Id ou subdomínio. Guard injeta no request.workspace e a Prisma middleware adiciona workspaceId em toda query.
// apps/api/src/tenant/workspace.guard.ts
@Injectable()
export class WorkspaceGuard implements CanActivate {
constructor(private prisma: PrismaService) {}
async canActivate(ctx: ExecutionContext): Promise<boolean> {
const req = ctx.switchToHttp().getRequest<FastifyRequest>();
const user = (req as any).user as AuthUser;
if (!user) throw new UnauthorizedException();
const wsHeader = req.headers['x-workspace-id'] as string | undefined;
const slug = (req.hostname.split('.')[0]) ?? null;
const ws = await this.prisma.workspace.findFirst({
where: {
OR: [{ id: wsHeader }, { slug }],
members: { some: { userId: user.id } },
},
});
if (!ws) throw new ForbiddenException('no_workspace_access');
(req as any).workspace = ws;
return true;
}
}
// packages/db/src/middleware/tenant.ts
prisma.$extends({
query: {
$allModels: {
async $allOperations({ args, query, model, operation }) {
const ws = workspaceContext.get();
if (!ws) return query(args);
const hasField = ['Content','Lead','AiJob','PixelEvent'].includes(model);
if (!hasField) return query(args);
if (operation.startsWith('find') || operation === 'count') {
args.where = { ...args.where, workspaceId: ws.id };
}
if (operation === 'create') {
args.data = { ...args.data, workspaceId: ws.id };
}
return query(args);
},
},
},
});
Better-Auth + Magic Link
Padrão magic link (e-mail). Senha opcional via campo extra. JWT em cookie HttpOnly + SameSite=Lax. Refresh rotation com janela de 14 dias.
// packages/auth/src/index.ts
import { betterAuth } from 'better-auth';
import { magicLink } from 'better-auth/plugins';
import { prismaAdapter } from 'better-auth/adapters/prisma';
import { prisma } from '@expoent/db';
export const auth = betterAuth({
database: prismaAdapter(prisma, { provider: 'postgresql' }),
plugins: [
magicLink({
sendMagicLink: async ({ email, url }) => {
await sendEmail({
to: email,
subject: 'Seu link de acesso · expoent',
html: renderTemplate('magic-link', { url }),
});
},
}),
],
session: {
expiresIn: 14 * 24 * 60 * 60, // 14 dias
updateAge: 24 * 60 * 60, // rotaciona diariamente
},
advanced: {
cookies: {
sessionToken: {
attributes: { httpOnly: true, sameSite: 'lax', secure: true },
},
},
},
});
BullMQ · workers de IA e pixel
Geração de imagem/vídeo nunca bloqueia o request. Worker pega da fila, chama provider, salva resultado, emite evento via Redis Pub/Sub pro frontend (websocket).
// apps/workers/src/queues/ai-image.worker.ts
import { Worker } from 'bullmq';
import { redis } from '../redis';
import { generateImage } from '@expoent/ai/image';
const worker = new Worker(
'ai-image',
async (job) => {
const { jobId, workspaceId, prompt, model } = job.data;
const start = Date.now();
await prisma.aiJob.update({
where: { id: jobId },
data: { status: 'RUNNING' },
});
try {
const out = await generateImage({ prompt, model });
await prisma.aiJob.update({
where: { id: jobId },
data: {
status: 'DONE',
output: { url: out.url, meta: out.meta },
costCents: out.costCents,
durationMs: Date.now() - start,
finishedAt: new Date(),
},
});
// notifica frontend via pub/sub
await redis.publish(`ws:${workspaceId}`, JSON.stringify({
kind: 'ai_job_done', jobId,
}));
} catch (err) {
await prisma.aiJob.update({
where: { id: jobId },
data: { status: 'FAILED', errorMsg: (err as Error).message },
});
throw err;
}
},
{ connection: redis, concurrency: 10 },
);
Pino + Sentry + Health
Logs estruturados
nestjs-pino com correlation-id por request, sanitização automática de PII, exportação em JSON.
Erros
Sentry capture em filtros globais; performance tracing 10% sample em prod, 100% em dev.
Health checks
GET /api/healthz verifica DB + Redis + workers em @nestjs/terminus.
Métricas
Prometheus em /metrics com histogramas de latência e contadores de jobs.
Estratégia recomendada
Fly.io ou Railway pra começar; AWS ECS Fargate quando passar de 5k usuários ativos.
Fly.io · 0–5k usuários
2 apps (api + workers), Neon Postgres, Upstash Redis. ~U$80/mês.
Railway · 5k–25k
Auto-scale por CPU, Postgres dedicado, Redis cluster. ~U$300/mês.
AWS ECS · 25k+
Fargate + RDS Multi-AZ + ElastiCache + CloudFront. Quando justificar.